feat: enhance agent runner - #404
Conversation
- convlock.go: gofmt field alignment in refCountedMutex - prompt.go: goimports local-prefix grouping (github.com/google/uuid must come before the github.com/Paca-AI/agent-runner group per .golangci.yml's local-prefixes setting) - use-conversation-event-window.ts: biome import sort order Verified: golangci-lint run (0 issues), bun run lint (0 issues), go build/vet/test -race all clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — Full initial review of all 23 files in commit bca8214 (agent-runner, apps/mcp, apps/web): read the entire diff front to back, traced every changed seam against the surrounding code, verified the third-party Goose provider claims against block/goose source, and ran the touched Go packages' unit tests (all pass under -race).
- Per-conversation trigger serialization — a refcounted per-
conversation_idlock in the Valkey consumer guarantees two triggers for the same conversation never runHandlerconcurrently, closing the in-memoryevent_indexincrement and in-flight-registry races at their root; lock is acquired before the semaphore slot so queued triggers don't exhaust it. - Token-owned in-flight registrations —
Register/Unregisternow hand back an ownership token so a stale deferredUnregistercan't clear a newer turn's entry (pause/resume turn overlap case);TeardownPausedChatSandboxre-checksIsRegisteredbefore popping so the idle reaper or a stop can't tear a resuming turn's sandbox out from under it. - ACP bridge reconnect leak fix —
Registernow cancels, closes, and waits on a superseded same-process connection, so its forwarder/eviction-watcher goroutines and Redis Pub/Sub subscription actually exit instead of running forever against an orphaned connection. - Bundled-skills failure surfaced as terminal — the conversation is marked
runningbefore skill load, so a transientservices/apioutage duringBundledSkills.Loadlands as a visiblefailedstatus + ack instead of an unacked message that is never actually redelivered (no XCLAIM in this consumer). - Goose provider ID aliases —
gemini→googleanddeepseek→custom_deepseekvia an explicit alias table; verified againstblock/goosesource (GOOGLE_PROVIDER_NAME,deepseek.json). Both previously failed to initialize silently for every conversation on those providers. - Global-chat and automation prompt context — nil-project triggers get a global-agent context block instead of a bogus nil-UUID framing; automation-fired triggers get the "no human watching" note;
PACA_ACTOR_USER_IDis now sourced fromActorUserID(wasActorMemberID, a member id mislabeled as a user id) so global-chat identity reaches apps/mcp. - Turn-scoped diff baseline — a per-path baseline lets a second edit of the same file in one turn diff incrementally against the first edit's result rather than always against HEAD.
- Sandbox image-confirmation cache —
ensureImageskipsImageList/ImagePullentirely for an already-confirmed pinned ref on subsequent sandbox starts. - Realtime tail-cache pruning —
useConversationEventWindowprunes the tail buffer down to events past the fetched window once a real fetch covers them, bounding a previously unbounded per-tab growth. clone_repositorydelete guard —assertSafeDeleteTargetrefuses the recursivermagainst a hand-picked set of top-level system directories regardless of what path the agent supplies, closing a prompt-injection crater the Python port it replaces never had.- Docs — new
services/agent-runner/README.mdand expanded architecture doc, including the already-wiredPACA_MCP_DEV_SOURCE_DIR.
Every load-bearing claim in the doc comments checks out against the code and — where it matters (provider IDs) — against upstream Goose source. The concurrency fixes are subtle and each carries a regression test that genuinely fails without the fix. Nothing to block or change.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — Incremental delta since the prior review (commit bca8214): the new commit 479e0ca ("fix: resolve CI lint failures") is purely mechanical formatting — gofmt field alignment in convlock.go, goimports grouping in prompt.go, and a biome import-order fix in use-conversation-event-window.ts. No behavioral surface.
The original substantive review stands; nothing in this commit changes any of the previously-reviewed behavior.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…ety in agent runner
There was a problem hiding this comment.
ℹ️ No critical issues — the new commit is correct and well-tested. One minor consolidation suggestion inline.
Reviewed changes — Incremental delta since the prior review (commit 479e0ca): commit ab390b0 ("feat: implement per-key locking mechanism and enhance concurrency safety in agent runner").
- Per-agent
registerLocksin the ACP bridge —Registerswapped the single process-wideregisterMufor aconvlockLockskeyed peragent_id, so one agent's potentially unbounded eviction wait (<-prev.done) can no longer stall every other agent'sRegisteron this replica. Correct: presence, connection-map, and broadcast state are all strictly per-agent. Verifying testTestRegister_DifferentAgentsDoNotBlockOnEachOthersEvictiongenuinely blocks one agent's connector and asserts another'sRegistercompletes. resumeLockcloses the Handle-vs-Teardown check-then-act race —Handle's registrar-of-flight +ChatSandboxes.Getnow runs atomically, perconversation_id, withTeardownPausedChatSandbox'sIsRegistered-check +Pop, so a stop/reaper can't tear down the sandbox a resuming turn just read. Held only across the two fast in-memory ops, never for the whole turn. The race test uses realregistry.Conversations+chatsandbox.Registryand would fail without the lock.- New generic
internal/convlockpackage — per-key refcounted mutex used by both the handler and the bridge (see inline note). clone_repositoryhardening —/home/goose(the container user's home, parent of the default clone dir) added to the forbidden-delete set, with traversal test cases (/home/goose/../→/home).
Verified: go build ./..., go vet, and go test -race all clean on the touched packages (convlock, handler, acpbridge, messaging). Nothing to block.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — Incremental delta since the prior review (commit ab390b0): commit d641507 ("refactor: replace conversationLocks with convlock for improved trigger serialization") consolidates the duplicated per-key refcounted mutex.
- Deduplicated the per-key lock primitive — deleted
internal/messaging/convlock.go(conversationLocks) and switched themessaging.Consumerto the sharedconvlock.Lockspackage (convlock.New()/Lock(trigger.ConversationID)), with doc comments updated to match. This directly implements the consolidation suggestion from the prior review. - Behavior-preserving —
convlock.Locksis byte-for-byte the same refcounted mutexconversationLockswas; the per-conversation_idserialization (lock acquired before the semaphore slot) and the regression test are unchanged. Verified the lock is still held only across the semaphore-acquire and handler call, never the whole run loop.
Verified locally: no lingering references to the old symbols, go build ./... and go vet ./... clean, and go test -race ./internal/messaging/... ./internal/convlock/... pass, including TestConsumer_SerializesTriggersForTheSameConversation. Consolidation done as suggested; nothing to block.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
ℹ️ No critical issues — the new preset instructions are well-formed and their
## Skill: …anchors genuinely resolve below the system prompt at runtime; two minor prompt-model notes inline.
Reviewed changes — Incremental delta since the prior Pullfrog review (commit d641507): the new commit 1eb96dee (single file, apps/web/src/lib/agent-api.ts). Each bundled LLM preset's defaultSystemPrompt now appends: "when you're assigned a task with nothing else said, skip skill-routing analysis and go straight to the <preset> skill (## Skill: <name> below)" — software-engineer → paca-do, code-reviewer & qa-engineer → paca-test, planner → paca-sprint, business-analyst → paca-clarify.
- Prompt text, no code changes — the create-agent dialog stores these strings as the agent's
system_prompt, whichagent-runner'sbuildInitialMessageemits first, followed by every enabled bundled skill as## Skill: <name>sections. Confirmed againstservices/agent-runner/internal/executor/prompt.go:83-96andservices/api/internal/platform/bundledskills— all four referenced skills exist as agent-flavor entries (noneCLIOnly), so nothing dangles. - Consistent injection for every LLM conversation — the handler always prepends the bundled agent-flavor set to
cfg.Skills(handler.go:135-152), so the referenced sections are always present below the system prompt regardless of per-agent skill customization.
Two optional refinements inline (both non-blocking observations, not defects).
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
- Introduced unit tests for skill frontmatter validation and file preparation. - Enhanced the `prepareFileSkills` function to ensure all enabled skills are correctly processed. - Implemented tar file creation for skills, ensuring proper structure and content. - Added end-to-end tests to verify system prompt delivery and skill loading behavior. - Updated Dockerfile to pin to a specific Goose image version for reproducibility. - Refactored bundled skills documentation to clarify skill loading requirements.
There was a problem hiding this comment.
ℹ️ No critical issues — the skill-delivery rework and async-ACP client are thorough, well-documented, and genuinely tested (
go test -raceclean on the touched packages). One minor resource-lifecycle note inline.
Reviewed changes — Incremental delta since the prior Pullfrog review (commit 1eb96dee): commit a2a16dd1 ("feat: add tests for skill handling and improve skill delivery via Goose").
- Moved skill delivery to Goose's native file mechanism — every enabled skill (custom ones with no frontmatter included) is now written to the sandbox as a real
SKILL.mdunder.agents/skills/via a tar (executor/skills.go) and discovered throughload_skill, instead of being folded into the first-turn prompt; the agent's own system prompt plus a mandatoryload_skill(paca)bootstrap now ride a.goosehintsfile (executor/hints.go). - Rewrote the ACP client for Goose 1.46's async transport — POSTs get a bare
202, responses arrive on connection/session-scoped SSE streams correlated by JSON-RPC id, with background reader goroutines torn down viaClient.Close();session/newMCP servers + the skills extension ship through_meta.enabledExtensionswith stdio env as names only. - Bumped the sandbox image to
ghcr.io/aaif-goose/goose@sha256:3c961bac…(1.46.0, the first that ships the skills feature), addedsandbox.CopyToContainer, and wiredClient.Close()into bothtearDownSandboxandTeardownPausedChatSandbox. - Updated prompts/presets — the bundled
pacaskill and the web preset system prompts now route viaload_skillby name rather than referencing inlined## Skill:sections. - Tests — frontmatter/tar/hints/async-client unit tests plus a real-Docker e2e test (gated on
PACA_E2E=1) asserting the system prompt reaches the system role and no skill body is ever folded into a message.
Verified go build, go test, and go test -race clean on internal/acp, internal/executor, internal/handler, and internal/sandbox.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…isolation and access
There was a problem hiding this comment.
Important
The DinD sidecar is a careful, well-documented design with real isolation tests, but one networking detail will intermittently break sandbox startup in production deploy mode — a must-fix before merge (see the inline comment). One follow-up operational concern in the body.
Reviewed changes — Incremental delta since the prior pullfrog review (commit a2a16dd): commit d3318f4 ("feat(agent): implement Docker-in-Docker sidecar for per-conversation isolation and access").
- Added a per-conversation
docker:27-dindsidecar (internal/sandbox/dind.go) on a private bridge network only the paired sandbox container joins, with the sandbox'sDOCKER_HOSTpointed at the sidecar by deterministic name over plaintext port 2375. - Wired sidecar lifecycle into
sandbox.Start/Stop— the sidecar is created before the sandbox's own image/container (soDOCKER_HOSTis in place from the first line), torn down on every failure path via thesidecarOKdefer, and force-NetworkDisconnected before network teardown inStop. - Switched the agent-server image to run as
rootand installed thedockerCLI (services/agent-server/Dockerfile). - Added e2e coverage: root package-manager ops +
docker run hello-world, and per-conversation sidecar isolation; plus unit tests for the deterministic naming helpers. - Deploy config (
docker-compose.dev/prod) mounts the Docker socket and documents the dedicated-host security posture.
ℹ️ Orphaned privileged Dind sidecars and networks on process death
stopDindSidecar and NetworkDisconnect only run in-process inside Sandbox.Stop. If the agent-runner process dies abruptly (crash, SIGKILL, pod eviction), every conversation's sidecar — a privileged container holding 2 CPU/4 GiB, plus its private network — is left running indefinitely on the host with no startup sweep of paca.managed artifacts. Because dindContainerName is deterministic, a later start for the same conversation would then also hit a container-name conflict with the orphaned sidecar. Worth adding a best-effort startup reaper that force-removes orphaned labelManaged containers/networks.
Technical details
# Orphaned protected Dind sidecars on process death
## Affected sites
- services/agent-runner/internal/sandbox/dind.go:192-201 — stopDindSidecar (only called from Stop / Start-failure defer)
- services/agent-runner/internal/sandbox/sandbox.go:390-406 — teardown in Stop
## Required outcome
- A crash/restart should not leave privileged dind sidecars and their networks running forever, and must not make later same-conversation starts fail with a container-name conflict.
## Suggested approach
- On Manager construction (or service startup), list containers/networks by `labelManaged=true` and force-remove orphans whose owning conversation is no longer active.DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…enhanced task execution
There was a problem hiding this comment.
ℹ️ The new commit is clean — no new issues in this delta. Not approving only because the PR still carries the open
[!IMPORTANT]containerIP must-fix from thed3318f4review, which this commit doesn't touch.
Reviewed changes — Incremental delta since the prior review (commit d3318f4): commit 8922c7e ("feat(agent): add general-purpose developer tools to Docker image for enhanced task execution"), a single-file change to services/agent-server/Dockerfile.
- Added a general-purpose developer-tool layer — a new apt layer installs
ripgrep,tree,jq,unzip,zip,less,nano,sqlite3,openssh-client,python3-pip,build-essential,postgresql-client, andshellcheckinto the sandbox image so a conversation doesn't fall back on slow workarounds orapt-get installmid-task. Closed withapt-get autoremoveand/var/lib/apt/lists/*cleanup. - Kept
curl(previously purged likegnupg) since it's now itself one of the general-purpose tools. - Documented rationale — the layer is thoroughly commented:
rg/treeclose a real gap vs Goose's own default system prompt,--no-install-recommendsavoids pulling inpython3-devforpython3-pip, and the +106MB measured size (build-essential being most of it) stays within the layer's stated 256MB budget.
The change is a well-documented, size-measured image build addition with no behavioral or correctness issues to raise. It leaves the three open review threads from prior commits untouched (containerIP must-fix, coldStart connStream leak, orphaned-sidecar concern); the containerIP thread in particular still blocks merge.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…and improve network handling in Docker
There was a problem hiding this comment.
ℹ️ All four prior review threads are resolved and the delta builds/vets clean — no new issues. One minor test-coverage gap inline.
Reviewed changes — Incremental delta since the prior Pullfrog review (8922c7e): commit 9cba883 (formatting-only, acknowledged via progress) and commit b2d0bc36.
- Preset prompts now explicitly override
pacarouting — every bundled preset'sdefaultSystemPromptinagent-api.tsinserts the clause "this overrides thepacaskill's own status-based routing table", and the planner preset scopespaca-sprintto "a specific existing task (as opposed to a goal to break down from scratch)". Resolves both open preset threads. coldStartcloses the ACP client onNewSessionfailure —client.Close()is now called (executor.go:302) before returningnil, tearing down the connection-scoped SSE reader started by the successfulInitializeinstead of leaking it. This implements the previously-flagged fix exactly. Resolves the open connStream-leak thread.- Deterministic
containerIPnetwork selection —containerIPgained apreferredNetworkparameter; theinsideDockerbranch passesownNetName, so the solewaitForReadycandidate is always the process-reachable own-network IP rather than a coin-flip between the sandbox's two networks (with the non-containerized branch passing""to keep the localhost fallback). Directly implements the blocking must-fix. Resolves that thread.
Verified go build ./... and go vet ./internal/sandbox/... ./internal/executor/... clean locally.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — Incremental delta since the prior Pullfrog review (b2d0bc3, review #4949294029): commit 4ebba70f ("feat(agent): enhance container IP selection logic and add comprehensive unit tests").
selectContainerIPextracted as a pure function + regression tests —containerIP's network-selection rule is pulled out of theContainerInspectcall into a standaloneselectContainerIP(networks, preferredNetwork) (string, bool), then covered by four unit tests (preferred-network selection, absent-preferred fallback, empty-preferred acceptance, and no-valid-address →false). This directly closes the minor test-coverage gap from the prior review: the previously-criticalcontainerIPcoin-flip fix now has real lock-in, exercised without needing a Docker daemon.
Verified locally: go test ./internal/sandbox/... passes, including the four new selectContainerIP tests. The prior thread (sandbox.go:591) is resolved. The refactor is behavior-preserving and well-documented; nothing to block.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Summary
Fixes 15 findings from a full code review of the
services/agent-runnermigration (services/ai-agentPython → Go/Goose), covering correctness bugs, concurrency races, a path-safety gap, and one efficiency issue — plus new documentation for the service. Every fix ships with a regression test; the concurrency fixes are additionally verified undergo test -race, and each of those tests was confirmed to actually catch its bug (temporarily reverted the fix locally, watched the test fail, restored it).Correctness
buildMCPServerssentPACA_ACTOR_USER_IDfromtrigger.ActorMemberIDinstead oftrigger.ActorUserID— the former is set on every project-scoped trigger and gets rejected byservices/api'sverifyAgentIdentity(which only accepts an actor-user-id claim for a global-scope agent), breakingget_task,clone_repository, and every other MCP tool call during normal project chat.buildInitialMessageunconditionally renderedYou are working inside project \00000000-0000-0000-0000-000000000000`` for global-chat conversations instead of the intended "you are a global agent" framing.resolveProviderEnvpassed Paca'sllm_providervalue straight through asGOOSE_PROVIDER, but Goose registers Gemini as"google"and DeepSeek as"custom_deepseek"— verified directly againstblock/goose's source (a public docs page for Goose turned out to be wrong about this).coherehas no Goose provider at all; left mapped (with a comment explaining why) so it fails with a clear "unknown provider" error instead of silently misrouting through the OpenAI fallback.BundledSkills.Loadcould fail before the conversation was ever markedrunning, and this service's Valkey consumer has no redelivery mechanism, so the conversation just sat there. Reordered sorunningis written first; a load failure now marks the conversationfailedwith the underlying error.git HEAD, so the second edit's diff card showed both edits combined. Now tracks a per-turn baseline per file.clone_repositoryrecursively force-deleted an agent-suppliedtargetDirwith no validation — a task that got the agent to pass/,/home, or/etcwould wipe it out. Now refuses a short list of protected top-level directories.Concurrency safety
Root cause: nothing prevented two triggers for the same
conversation_idfrom runningHandle()concurrently, which enabled three related races:event_indexis allocated once per turn and incremented in-memory afterward; two concurrent turns could allocate the same index, andInsertEvent'sON CONFLICT DO NOTHINGsilently dropped the loser's events.registry.Conversations.Register/Unregisterhad no ownership check, so a paused turn's deferredUnregistercould delete a newer turn's live cancel entry.Fixed at the root:
internal/messaging.Consumernow serializes trigger handling perconversation_id(different conversations still run concurrently). Layered with defense-in-depth:Registernow returns an ownership tokenUnregistermust match,Handle()registers in-flight before reading the paused sandbox, andTeardownPausedChatSandboxre-checksInFlight.IsRegisteredbefore popping.Also fixed a real goroutine/Redis-subscription leak in the ACP bridge:
acpbridge.Registry.Registeroverwrote the connections map with no reference to the previous entry, so a same-process reconnect left the old connection's forwarder goroutine running forever.Efficiency
sandbox.Manager.ensureImagecalleddocker.ImageList(enumerating every image on the host) on every single conversation start, even though the image is pinned for the process's lifetime. Now caches "confirmed present" after the first check.Documentation
services/agent-runner/README.md— responsibilities, stack, source layout, local development (including the Docker-daemon/Postgres/Valkey prerequisite this service needs, unlikeservices/realtime's standalone dev loop), environment variables, testing, linting.docs/ai-agent/agent-runner-service.mdto document the per-conversation serialization guarantee, the registry ownership-token safety, the chat-sandbox teardown guard, the provider-id alias table, theclone_repositorypath-safety guard, and a previously-undocumented env var (PACA_MCP_DEV_SOURCE_DIR).Test plan
go build ./...andgo vet ./...clean acrossservices/agent-runnergo test -race ./...clean, including new regression tests for every fixapps/mcp:bun run test(551 tests) andtsc --noEmitcleanapps/web:bun run test(554 tests) andtsc --noEmitclean